1"use client";
2
3
14import { useParams } from "next/navigation";
15import { useQuery } from "@tanstack/react-query";
16import { CreditCard } from "lucide-react";
17import { useWorkspace } from "@/hooks/use-workspace";
18import { getWorkspaceUsage } from "@/lib/files/api";
19import { SettingsShell, Section } from "@/components/settings/settings-shell";
20import { cn } from "@/lib/utils";
21
22function formatBytes(n: number): string {
23 if (!n) return "0 B";
24 const units = ["B", "KB", "MB", "GB", "TB"];
25 const i = Math.min(units.length - 1, Math.floor(Math.log2(n) / 10));
26 return `${(n / 2 ** (10 * i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
27}
28
29function barPct(used: number, limit: number | null): number | null {
30 if (limit == null || limit <= 0) return null;
31 return Math.min(100, Math.round((used / limit) * 100));
32}
33
34export default function SettingsBillingPage() {
35 const params = useParams<{ workspace: string }>();
36 const slugOrId = params?.workspace ?? "";
37 const { data: ws, isLoading } = useWorkspace(slugOrId);
38
39 const usage = useQuery({
40 queryKey: ["workspaces", ws?.id ?? slugOrId, "usage"],
41 queryFn: () => getWorkspaceUsage(ws?.id ?? slugOrId),
42 enabled: !!ws?.id
43 });
44
45 const u = usage.data?.usage;
46 const l = usage.data?.limits;
47
48
49
50 const planLabel = (
51 (ws as unknown as { billingPlan?: string } | null)?.billingPlan ?? ws?.plan ?? "free"
52 ).toLowerCase();
53
54
55
56 const storageUsed = u ? Number(u.storageBytes) : 0;
57 const storageLimit = l?.maxStorageBytes ?? null;
58 const storagePct = storageLimit != null && storageLimit > 0
59 ? Math.min(100, (storageUsed / storageLimit) * 100)
60 : null;
61
62 const rows: { label: string; used: number; limit: number | null; format: (n: number) => string }[] = u
63 ? [
64 { label: "Pages", used: u.pages, limit: l?.maxPages ?? null, format: (n) => `${n.toLocaleString()}` },
65 { label: "Databases", used: u.databases, limit: l?.maxDatabases ?? null, format: (n) => `${n.toLocaleString()}` },
66 { label: "Records", used: u.records, limit: l?.maxRecords ?? null, format: (n) => `${n.toLocaleString()}` },
67 { label: "Storage", used: storageUsed, limit: storageLimit, format: formatBytes },
68 { label: "API keys", used: u.apiKeys, limit: l?.maxApiKeys ?? null, format: (n) => `${n}` },
69 { label: "Webhooks", used: u.webhooks, limit: l?.maxWebhooks ?? null, format: (n) => `${n}` }
70 ]
71 : [];
72
73 return (
74 <SettingsShell title="Billing" description="Plan and resource usage." active="billing">
75 {isLoading || !ws ? (
76 <p className="text-sm text-muted-foreground">Loading…</p>
77 ) : (
78 <div className="space-y-5">
79 <Section title="Plan">
80 <div className="flex items-center gap-3 rounded-lg border border-border p-4">
81 <span className="flex size-10 items-center justify-center rounded-md bg-muted">
82 <CreditCard className="size-5 text-muted-foreground" aria-hidden />
83 </span>
84 <div className="flex-1">
85 <p className="text-sm font-medium capitalize">{planLabel} plan</p>
86 <p className="text-xs text-muted-foreground">
87 Self-hosted — no billing portal. Usage limits are configured by the administrator.
88 </p>
89 </div>
90 </div>
91 </Section>
92
93 {}
94 <Section title="Storage">
95 <div className="rounded-lg border border-border p-4">
96 <div className="mb-1 flex items-baseline justify-between gap-2 text-sm">
97 <span className="text-muted-foreground">Storage used</span>
98 <span className="text-xs">
99 <span className="font-medium text-foreground">
100 {usage.isLoading ? "…" : formatBytes(storageUsed)}
101 </span>
102 <span className="text-muted-foreground">
103 {" / "}
104 {storageLimit == null ? "unlimited" : formatBytes(storageLimit)}
105 </span>
106 {storagePct != null ? (
107 <span className={cn("ml-1 font-medium", storagePct >= 90 ? "text-red-600 dark:text-red-400" : "text-muted-foreground")}>
108 {Math.round(storagePct)}%
109 </span>
110 ) : null}
111 </span>
112 </div>
113 <div
114 className="h-2 overflow-hidden rounded-full bg-muted"
115 role="progressbar"
116 aria-label="Storage used"
117 aria-valuenow={storagePct != null ? Math.round(storagePct) : undefined}
118 aria-valuemin={0}
119 aria-valuemax={100}
120 >
121 <div
122 className={cn(
123 "h-full rounded-full transition-all",
124 storagePct == null
125 ? "bg-primary"
126 : storagePct >= 90
127 ? "bg-red-600"
128 : storagePct >= 70
129 ? "bg-amber-500"
130 : "bg-primary"
131 )}
132 style={{ width: `${storagePct ?? 0}%` }}
133 />
134 </div>
135 <p className="mt-2 text-[11px] text-muted-foreground capitalize">{planLabel} plan quota.</p>
136 </div>
137 </Section>
138
139 <Section title="Usage">
140 <ul className="space-y-3">
141 {rows.map((r) => {
142 const pct = barPct(r.used, r.limit);
143 return (
144 <li key={r.label}>
145 <div className="mb-1 flex items-baseline justify-between text-sm">
146 <span className="text-muted-foreground">{r.label}</span>
147 <span className="text-xs">
148 <span className="font-medium text-foreground">{r.format(r.used)}</span>
149 <span className="text-muted-foreground"> / {r.limit == null ? "unlimited" : r.format(r.limit)}</span>
150 </span>
151 </div>
152 {pct != null && (
153 <div className="h-1.5 overflow-hidden rounded-full bg-muted">
154 <div
155 className={`h-full rounded-full ${pct >= 90 ? "bg-destructive" : pct >= 70 ? "bg-amber-500" : "bg-primary"}`}
156 style={{ width: `${pct}%` }}
157 />
158 </div>
159 )}
160 </li>
161 );
162 })}
163 </ul>
164 {!usage.isLoading && rows.length === 0 && (
165 <p className="text-sm text-muted-foreground">Usage data unavailable.</p>
166 )}
167 </Section>
168 </div>
169 )}
170 </SettingsShell>
171 );
172}
173